Java Spring Boot 快速上手指南

本文面向有多年前端开发经验、熟悉 Node.js 后端(Express / EggJS / NestJS)的开发者, 从零搭建一个 Spring Boot 项目, 逐步深入到分层架构、数据库操作、安全认证、缓存、消息队列等企业级实践。每个概念都会与前端 / Node.js 生态中的对应物做类比, 帮你快速建立 Java 世界的心智模型
一、为什么是 Spring Boot
在 Java 生态中, Spring 就像前端的 React / Vue 一样, 是事实上的标准框架。而 Spring Boot 则是对 Spring 的开箱即用封装, 类似于 create-react-app 之于 React, 或者 EggJS 之于 Koa
对前端开发者的类比
| Java / Spring Boot | Node.js / 前端 |
|---|---|
| Spring Boot | EggJS / NestJS(约定式框架) |
| Maven / Gradle | npm / pnpm(包管理 + 构建) |
application.yml | .env + config/ 配置文件 |
@RestController | Express 的 router / Koa 的 controller |
@Service | EggJS 的 service 层 |
@Repository (JPA) | Sequelize / TypeORM / Prisma |
@Autowired (依赖注入) | NestJS 的 @Inject() |
| Spring AOP | Express 中间件 / NestJS 拦截器 |
pom.xml / build.gradle | package.json |
Spring Boot 的核心优势
- 自动配置 (Auto Configuration): 引入一个 starter 依赖, 相关配置自动生效, 无需手动 XML 配置
- 内嵌服务器: 自带 Tomcat, 不需要单独部署 WAR 包, 直接
java -jar启动 - Starter 依赖: 一个 starter 打包了一组常用依赖, 类似于
create-react-app帮你预装了 webpack + babel + eslint - 生产就绪: 内置健康检查、指标监控、外部化配置等运维能力
二、环境准备与项目创建
2.1 环境清单
| 工具 | 版本要求 | 说明 |
|---|---|---|
| JDK | 17+ (推荐 21 或 25, 均为 LTS) | Java 开发工具包, 类似 Node.js 运行时 |
| Maven 或 Gradle | Maven 3.8+ / Gradle 7+ | 构建工具, 类似 npm / pnpm |
| IDE | IntelliJ IDEA (推荐) | Java 开发首选, 类似前端的 VS Code |
macOS 快速安装
# 使用 SDKMAN 管理 Java 版本 (类似 nvm)
curl -s "https://get.sdkman.io" | bash
sdk install java 21-tem # 或 25-tem, 两者都是 LTS
# 验证
java -version2.2 创建项目
访问 Spring Initializr 生成项目骨架, 这就像前端用 npm create vite@latest 创建项目一样
推荐初始依赖
- Spring Web: 构建 REST API 的核心
- Spring Data JPA: ORM 框架, 类似 TypeORM / Prisma
- MySQL Driver: 数据库驱动
- Lombok: 减少 Java 样板代码(getter / setter / constructor)
- Spring Boot DevTools: 热重载, 类似 nodemon
- Validation: 参数校验, 类似 class-validator
2.3 项目目录结构
生成后的项目结构如下, 与 EggJS 的约定式目录有异曲同工之妙:
my-project/
├── src/
│ ├── main/
│ │ ├── java/com/example/demo/ # Java 源码 (类似 src/)
│ │ │ ├── DemoApplication.java # 入口文件 (类似 app.js / main.ts)
│ │ │ ├── controller/ # 控制器 (类似 router + controller)
│ │ │ ├── service/ # 业务逻辑 (类似 service/)
│ │ │ ├── repository/ # 数据访问 (类似 model/)
│ │ │ ├── entity/ # 实体类 (类似 ORM 的 entity)
│ │ │ ├── dto/ # 数据传输对象 (类似 TS 的 interface/type)
│ │ │ └── config/ # 配置类 (类似 config/)
│ │ └── resources/
│ │ ├── application.yml # 主配置 (类似 .env + config)
│ │ ├── application-dev.yml # 开发环境配置
│ │ └── application-prod.yml # 生产环境配置
│ └── test/ # 测试 (类似 __tests__/)
├── pom.xml # Maven 依赖 (类似 package.json)
└── mvnw / gradlew # 构建工具 wrapper (类似 npx)三、Java 语言核心速览
写在前面
如果你熟悉 TypeScript, 那 Java 对你来说并不陌生。Java 是强类型语言, TypeScript 的类型系统正是受 Java / C# 启发而来
3.1 类型系统对比
// 基本类型 (Primitive Types)
int count = 10; // TS: number
long bigCount = 100000L; // TS: number (大整数)
double price = 99.9; // TS: number
boolean active = true; // TS: boolean
String name = "Atom"; // TS: string
// 包装类型 (可以为 null, 类似 TS 的 number | null)
Integer nullableCount = null;
Long nullableLong = null;
// 集合类型
List<String> names = List.of("a", "b"); // TS: string[]
Map<String, Object> map = Map.of("key", "val"); // TS: Record<string, any>
Set<String> uniqueNames = Set.of("a", "b"); // TS: Set<string>// 基本类型
let count: number = 10;
let bigCount: number = 100000;
let price: number = 99.9;
let active: boolean = true;
let name: string = "Atom";
// 可空类型
let nullableCount: number | null = null;
// 集合类型
let names: string[] = ["a", "b"];
let map: Record<string, any> = { key: "val" };
let uniqueNames: Set<string> = new Set(["a", "b"]);3.2 类与接口
// Java 的 class 比 TS 更严格, 一个文件通常只有一个 public class
public class User {
private Long id;
private String name;
private String email;
// 构造函数
public User(Long id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
}
// Getter / Setter (Java 的传统, 类似 TS 的属性访问)
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}// Lombok 通过注解自动生成 getter/setter/constructor
// 类似 TS 直接声明 public 属性
@Data // 自动生成 getter + setter + toString + equals + hashCode
@NoArgsConstructor // 无参构造
@AllArgsConstructor // 全参构造
public class User {
private Long id;
private String name;
private String email;
}// TS 的 class, 简洁很多
class User {
constructor(
public id: number,
public name: string,
public email: string
) {}
}Lombok 必知注解
| 注解 | 作用 | TS 类比 |
|---|---|---|
@Data | 生成 getter/setter/toString/equals/hashCode | class 的 public 属性 |
@Builder | 生成 Builder 模式构造 | 对象字面量 { ...spread } |
@NoArgsConstructor | 生成无参构造 | constructor() |
@AllArgsConstructor | 生成全参构造 | constructor(all params) |
@RequiredArgsConstructor | 生成 final 字段构造 | NestJS constructor(private readonly svc) |
@Slf4j | 生成日志对象 | const logger = console |
3.3 注解 (Annotation) = 装饰器 (Decorator)
Java 的注解 @Xxx 和 TypeScript / NestJS 的装饰器 @Xxx 几乎是同一个概念:
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) {
// ...
}
@PostMapping
public User createUser(@RequestBody @Valid CreateUserDTO dto) {
// ...
}
}@Controller('/api/users')
export class UserController {
@Get('/:id')
getUser(@Param('id') id: number) {
// ...
}
@Post()
createUser(@Body() dto: CreateUserDTO) {
// ...
}
}四、第一个 REST API
4.1 入口文件
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
// 类似 Node.js 的 app.listen(3000)
SpringApplication.run(DemoApplication.class, args);
}
}@SpringBootApplication 是一个组合注解, 它等价于:
4.2 编写 Controller
与 Express 的对比
在 Express 中, 你会这样写路由:
router.get('/api/hello', (req, res) => {
res.json({ message: 'Hello World' })
})在 Spring Boot 中, 用注解来声明路由:
@RestController
@RequestMapping("/api/v1")
public class HelloController {
@GetMapping("/hello")
public Map<String, String> hello() {
return Map.of("message", "Hello Spring Boot!");
}
@GetMapping("/hello/{name}")
public Map<String, String> helloName(@PathVariable String name) {
return Map.of("message", "Hello, " + name + "!");
}
@GetMapping("/search")
public Map<String, Object> search(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
return Map.of("page", page, "size", size);
}
}常用路由注解速查
| 注解 | HTTP 方法 | Express 对应 |
|---|---|---|
@GetMapping | GET | router.get() |
@PostMapping | POST | router.post() |
@PutMapping | PUT | router.put() |
@DeleteMapping | DELETE | router.delete() |
@PatchMapping | PATCH | router.patch() |
@PathVariable | 路径参数 | req.params.id |
@RequestParam | 查询参数 | req.query.page |
@RequestBody | 请求体 | req.body |
@RequestHeader | 请求头 | req.headers['x-token'] |
4.3 启动与验证
# Maven 项目
./mvnw spring-boot:run
# Gradle 项目
./gradlew bootRun
# 访问
curl http://localhost:8080/api/v1/hello
# {"message":"Hello Spring Boot!"}热重载
添加 spring-boot-devtools 依赖后, 修改代码会自动重启应用, 类似 nodemon。在 IDEA 中需要开启 Build project automatically 设置
五、配置管理
5.1 application.yml
Spring Boot 使用 application.yml(或 .properties)管理配置, 类似 Node.js 项目的 .env + config/
# application.yml - 主配置
server:
port: 8080 # 类似 Express 的 app.listen(8080)
spring:
application:
name: my-app # 应用名称
profiles:
active: dev # 激活的环境 (类似 NODE_ENV=development)
datasource:
url: jdbc:mysql://localhost:3306/mydb?useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update # 自动建表/更新表结构
show-sql: true # 打印 SQL (开发时开启)ddl-auto 千万不要带进生产
ddl-auto: update 让 Hibernate 按实体类反向修改表结构, 开发期方便, 生产环境是事故源: 它只加不减, 字段改名会变成"新增一列 + 旧列残留", 而某些版本对索引和约束的处理并不可靠
| 取值 | 行为 | 适用环境 |
|---|---|---|
none | 什么都不做 | 生产 (强制) |
validate | 只校验表结构与实体是否一致, 不一致就启动失败 | 生产 (推荐) |
update | 增量修改表结构 | 本地开发 |
create-drop | 启动建表, 关闭删表 | 单元测试 |
生产环境的表结构变更应交给 Flyway / Liquibase 管理(类似前端的数据库 migration 脚本), 版本化、可回滚、可审计
5.2 多环境配置
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/mydb_dev
logging:
level:
root: DEBUGserver:
port: 80
spring:
datasource:
url: jdbc:mysql://prod-db:3306/mydb_prod
logging:
level:
root: WARN5.3 自定义配置绑定
// 类似 Node.js 从 config 读取自定义配置
@Data
@Component
@ConfigurationProperties(prefix = "app")
public class AppConfig {
private String name;
private String version;
private Jwt jwt = new Jwt();
@Data
public static class Jwt {
private String secret;
private long expiration = 86400000; // 24h
}
}# application.yml
app:
name: my-app
version: 1.0.0
jwt:
secret: my-secret-key
expiration: 86400000// 在任何地方注入使用
@Service
@RequiredArgsConstructor
public class AuthService {
private final AppConfig appConfig;
public String getSecret() {
return appConfig.getJwt().getSecret();
}
}六、IoC 与依赖注入
先分清两个"依赖"
初学 Java 最容易混的一组概念: 依赖管理(Dependency Management)和 依赖注入(Dependency Injection)。名字都带"依赖", 但处在完全不同的阶段
| 依赖管理 | 依赖注入 | |
|---|---|---|
| 阶段 | 构建期 | 运行期 |
| 执行者 | Maven / Gradle | Spring 容器 |
| 改哪个文件 | pom.xml / build.gradle | Java 源码 |
| 解决什么 | 去哪里下载哪个 jar 包 | 把哪个实例塞进哪个字段 |
| 前端类比 | package.json 加一行依赖 | NestJS 的 constructor(private svc) |
所以日常写业务时加个注入点, 是不需要碰构建文件的:
// 只改这个 Java 文件就够了, build.gradle 不用动
@Service
@RequiredArgsConstructor
public class OrderService {
private final UserRepository userRepository; // 新增一行注入
private final RedisTemplate<String, Object> redisTemplate;
}只有一种情况需要两边都改: 要注入的类来自项目还没引入的库。比如想用 Excel 导出, 先在构建文件里声明依赖拿到"类", Spring 才能给你"实例":
// ① 第一步: build.gradle 拿到 jar (构建期)
implementation "org.apache.poi:poi-ooxml:5.2.5"// ② 第二步: 同步依赖后, 才能在代码里注入 (运行期)
private final ExcelExportService excelExportService;顺序永远是: 构建工具先拿到 类, Spring 容器才能给出 实例
6.1 什么是 IoC
IoC(Inversion of Control, 控制反转) 是 Spring 的灵魂。简单说就是: 你不需要自己 new 对象, Spring 容器帮你创建和管理
对前端的类比
如果你用过 NestJS, 它的依赖注入系统就是从 Spring 借鉴而来:
// NestJS - 和 Spring 几乎一模一样
@Injectable()
export class UserService {
constructor(private readonly userRepo: UserRepository) {}
}// Spring Boot
@Service
public class UserService {
private final UserRepository userRepo;
// 构造器注入 (推荐)
public UserService(UserRepository userRepo) {
this.userRepo = userRepo;
}
}6.2 Bean 的注册方式
Spring 中被容器管理的对象叫做 Bean, 类似 NestJS 中被 @Injectable() 标记的 Provider
| 注解 | 用途 | NestJS 对应 |
|---|---|---|
@Component | 通用组件 | @Injectable() |
@Service | 业务逻辑层 | @Injectable() (Service) |
@Repository | 数据访问层 | @Injectable() (Repository) |
@Controller / @RestController | 控制器 | @Controller() |
@Configuration + @Bean | 手动注册 | providers: [{ provide: ..., useFactory: ... }] |
6.3 注入方式
@Service
@RequiredArgsConstructor // Lombok 自动生成构造器
public class UserService {
// final 字段 = 必须注入, 类似 NestJS 的 private readonly
private final UserRepository userRepository;
private final RedisTemplate<String, String> redisTemplate;
}@Service
public class UserService {
// 不推荐: 无法在测试中轻松 mock
@Autowired
private UserRepository userRepository;
}为什么推荐构造器注入
- 不可变性:
final字段保证注入后不会被修改 - 可测试性: 测试时直接通过构造器传入 mock 对象
- 明确依赖: 一眼看出这个类依赖了什么
- NullSafe: 编译期就能发现缺失的依赖, 而不是运行时 NPE
6.4 Bean 的名字与冲突仲裁
容器里的每个 Bean 都有一个唯一的名字, 默认取类名首字母小写(UserService → userService), 用 @Bean 注册时则取方法名
平时按类型注入就能命中, 但当同一个类型存在多个 Bean 时, 容器就不知道该给哪个了, 启动直接失败:
NoUniqueBeanDefinitionException: expected single matching bean but found 2:
primaryDataSource, secondaryDataSource两个解决手段:
@Configuration
public class DataSourceConfig {
@Bean
@Primary // 同类型有多个候选时, 优先选我
public DataSource primaryDataSource() {
return buildDataSource("jdbc:mysql://main-db:3306/app");
}
@Bean
public DataSource secondaryDataSource() {
return buildDataSource("jdbc:mysql://backup-db:3306/app");
}
}@Service
public class ReportService {
private final DataSource dataSource;
// 不按类型猜了, 明确要名字叫 secondaryDataSource 的那个
// 注意: @Qualifier 必须写在构造器参数上
public ReportService(@Qualifier("secondaryDataSource") DataSource dataSource) {
this.dataSource = dataSource;
}
}@Qualifier 和 @RequiredArgsConstructor 有个坑
上面这段之所以手写构造器, 是因为 Lombok 默认不会把字段上的 @Qualifier 复制到它生成的构造器参数上。下面这种写法看着合理, 实际注入的仍是 @Primary 那个, 而且不报错:
@Service
@RequiredArgsConstructor
public class ReportService {
@Qualifier("secondaryDataSource") // ❌ 静默失效
private final DataSource dataSource;
}两个解法, 任选其一:
① 手写构造器(如上), 最直观, 不依赖额外配置
② 在项目根目录建 lombok.config, 告诉 Lombok 把这个注解带过去:
# lombok.config
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier配好之后字段写法才真正生效。这类"不报错但行为不对"的问题最难排查, 遇到多数据源、多线程池、多 RedisTemplate 这些同类型多 Bean 的场景要格外留神
注入 Map / List 可以批量收集同类型 Bean
这是个非常好用的技巧: 把注入目标声明成 Map 或 List, Spring 会把所有该类型的实现塞进来 —— Map 的 key 是 Bean 名字, List 则按 @Order 排序
@Component
@RequiredArgsConstructor
public class PaymentFactory {
// 容器把所有 PaymentHandler 实现都收集进来
private final Map<String, PaymentHandler> handlerMap;
public PaymentHandler get(String channel) {
return handlerMap.get(channel + "PaymentHandler");
}
}好处是加新实现不用改工厂类: 新写一个 @Service class WechatPaymentHandler implements PaymentHandler, 它自动出现在 map 里。这是策略模式在 Spring 里最省事的落地方式, 比手写 switch 干净得多
6.5 Bean 的作用域与线程安全
Spring Bean 默认是 单例(singleton): 整个应用只有一个实例, 被所有注入方共享。这跟 Node.js 里 module.exports 一个对象、全进程复用是同一个道理
| 作用域 | 说明 | 使用频率 |
|---|---|---|
singleton | 默认, 全局一个实例 | 99% |
prototype | 每次注入 / 获取都新建 | 少见 |
request | 每个 HTTP 请求一个 | Web 场景偶用 |
session | 每个会话一个 | 罕见 |
单例 Bean 里绝对不要放可变状态
Spring MVC 是多线程模型(一个请求一个线程), 单例 Bean 会被并发访问。把请求数据存成字段, 多个用户的数据会互相覆盖 —— 这类 bug 在本地单人测试时完全复现不出来, 上线才爆
@Service
public class BadService {
private Long currentUserId; // ❌ 灾难
public void handle(Long userId) {
this.currentUserId = userId; // 线程 A 写入
doSomething(); // 线程 B 可能已经改掉了
}
}@Service
@RequiredArgsConstructor
public class GoodService {
private final UserRepository userRepository; // ✅ 只存无状态的协作对象
public void handle(Long userId) {
// 请求数据一律走方法参数和局部变量
User user = userRepository.findById(userId).orElseThrow();
}
}判断标准很简单: 字段只放 final 的依赖引用, 数据全部走参数。前端写 Node.js 时如果踩过"模块级变量被并发请求污染"的坑, 这里是完全一样的机理
七、分层架构实战
一个完整的 Spring Boot 应用采用经典的三层架构, 与 EggJS / NestJS 如出一辙:
7.1 Entity 实体类
@Data
@Entity
@Table(name = "t_user")
@DynamicUpdate
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 50)
private String username;
@Column(nullable = false)
private String password;
@Column(unique = true, length = 100)
private String email;
@Column(name = "phone_number", length = 20)
private String phoneNumber;
@Enumerated(EnumType.STRING)
@Column(length = 20)
private UserStatus status = UserStatus.ACTIVE;
@Column(name = "created_at", updatable = false)
@CreationTimestamp
private LocalDateTime createdAt;
@Column(name = "updated_at")
@UpdateTimestamp
private LocalDateTime updatedAt;
public enum UserStatus {
ACTIVE, INACTIVE, BANNED
}
}与 TypeORM 的对比
// TypeORM Entity - 几乎一模一样的概念
@Entity('t_user')
export class User {
@PrimaryGeneratedColumn()
id: number
@Column({ nullable: false, length: 50 })
username: string
@CreateDateColumn()
createdAt: Date
}@Data 用在 Entity 上是个坑
@Data 会一并生成 toString / equals / hashCode, 这三个方法碰上 JPA 会出问题:
- 双向关联无限递归: 用户
toString打印部门, 部门toString又打印用户列表, 直接StackOverflowError - 懒加载被意外触发:
toString访问了LAZY字段, 在 session 关闭后抛LazyInitializationException - hashCode 不稳定: 实体存进
HashSet后再改字段,hashCode跟着变, 之后就再也取不出来了
实体类推荐这样写:
@Getter
@Setter
@Entity
@Table(name = "t_user")
@ToString(exclude = "department") // 排除关联字段
@EqualsAndHashCode(onlyExplicitlyIncluded = true) // 只用显式标记的字段
public class User {
@Id
@EqualsAndHashCode.Include // 只按主键判等
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
}DTO / VO 没有关联关系和持久化上下文, 用 @Data 完全没问题
7.2 DTO 数据传输对象
DTO 是 Controller 层和 Service 层之间的数据契约, 类似 TypeScript 的 interface + 校验规则:
// 创建用户请求 DTO
@Data
public class CreateUserDTO {
@NotBlank(message = "用户名不能为空")
@Size(min = 2, max = 50, message = "用户名长度 2-50 个字符")
private String username;
@NotBlank(message = "密码不能为空")
@Size(min = 6, max = 100, message = "密码长度 6-100 个字符")
private String password;
@Email(message = "邮箱格式不正确")
private String email;
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phoneNumber;
}
// 用户响应 DTO (不暴露密码等敏感字段)
@Data
@Builder
public class UserVO {
private Long id;
private String username;
private String email;
private String phoneNumber;
private String status;
private LocalDateTime createdAt;
}DTO vs Entity 的关系
- Request DTO: 接收前端参数 + 校验, 类似
class-validator的 DTO - Entity: 映射数据库表, 内部使用
- Response VO: 返回给前端的数据, 隐藏敏感信息
7.3 Repository 数据访问层
Spring Data JPA 让你只需定义接口, 不用写实现, 框架自动生成 SQL:
@Repository
public interface UserRepository extends JpaRepository<User, Long>,
JpaSpecificationExecutor<User> {
// 方法名即查询! Spring 自动生成 SQL
// SELECT * FROM t_user WHERE username = ?
Optional<User> findByUsername(String username);
// SELECT * FROM t_user WHERE email = ?
Optional<User> findByEmail(String email);
// SELECT * FROM t_user WHERE status = ? AND created_at > ?
List<User> findByStatusAndCreatedAtAfter(User.UserStatus status, LocalDateTime time);
// 支持分页
Page<User> findByStatus(User.UserStatus status, Pageable pageable);
// 复杂查询可以手写 JPQL
@Query("SELECT u FROM User u WHERE u.username LIKE %:keyword% OR u.email LIKE %:keyword%")
Page<User> searchByKeyword(@Param("keyword") String keyword, Pageable pageable);
// 判断是否存在
boolean existsByUsername(String username);
boolean existsByEmail(String email);
}这里的 @Repository 其实可以省略
@EnableJpaRepositories(已被 @SpringBootApplication 自动开启)会扫描并注册所有继承 Repository 的接口, 不依赖 @Repository 注解。加上它只是让 IDE 和阅读者一眼看出层次, 语义上是冗余的
真正需要 @Repository 的场景是自己手写实现类的数据访问组件 —— 此时要靠它完成 Bean 注册, 并顺带获得持久层异常翻译(把各家数据库驱动的异常统一成 Spring 的 DataAccessException)
方法命名规则
Spring Data JPA 通过解析方法名自动生成查询, 类似 Prisma 的 findUnique / findMany:
| 方法名关键字 | 生成的 SQL | Prisma 对照 |
|---|---|---|
findBy | WHERE | findMany({ where: {} }) |
And | AND | AND 条件 |
Or | OR | OR 条件 |
OrderBy | ORDER BY | orderBy |
Between | BETWEEN | gte + lte |
Like / Containing | LIKE %?% | contains |
In | IN (?) | in |
IsNull | IS NULL | equals: null |
count | SELECT COUNT(*) | count() |
existsBy | SELECT EXISTS | 手动查 |
7.4 Service 业务逻辑层
@Slf4j
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
/**
* 创建用户
*/
@Transactional
public UserVO createUser(CreateUserDTO dto) {
// 1. 业务校验
if (userRepository.existsByUsername(dto.getUsername())) {
throw new BusinessException("用户名已存在");
}
if (dto.getEmail() != null && userRepository.existsByEmail(dto.getEmail())) {
throw new BusinessException("邮箱已被注册");
}
// 2. DTO -> Entity
User user = new User();
user.setUsername(dto.getUsername());
user.setPassword(passwordEncoder.encode(dto.getPassword()));
user.setEmail(dto.getEmail());
user.setPhoneNumber(dto.getPhoneNumber());
// 3. 持久化
User saved = userRepository.save(user);
log.info("用户创建成功: id={}, username={}", saved.getId(), saved.getUsername());
// 4. Entity -> VO
return toVO(saved);
}
/**
* 分页查询用户
*/
public Page<UserVO> getUsers(int page, int size) {
Pageable pageable = PageRequest.of(page - 1, size, Sort.by("createdAt").descending());
return userRepository.findAll(pageable).map(this::toVO);
}
/**
* 根据 ID 查询
*/
public UserVO getUserById(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new BusinessException("用户不存在"));
return toVO(user);
}
/**
* Entity -> VO 转换
*/
private UserVO toVO(User user) {
return UserVO.builder()
.id(user.getId())
.username(user.getUsername())
.email(user.getEmail())
.phoneNumber(user.getPhoneNumber())
.status(user.getStatus().name())
.createdAt(user.getCreatedAt())
.build();
}
}@Transactional 事务管理
@Transactional 注解标记的方法, 会在一个数据库事务中执行, 任何异常都会自动回滚。类似 Sequelize 的:
await sequelize.transaction(async (t) => {
await User.create(data, { transaction: t })
})Spring 只需要加一个注解, 不用手动管理事务的开始和提交
7.5 Controller 控制器层
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/users")
public class UserController {
private final UserService userService;
@PostMapping
public ResponseEntity<UserVO> createUser(@RequestBody @Valid CreateUserDTO dto) {
UserVO user = userService.createUser(dto);
return ResponseEntity.status(HttpStatus.CREATED).body(user);
}
@GetMapping
public ResponseEntity<Page<UserVO>> getUsers(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
return ResponseEntity.ok(userService.getUsers(page, size));
}
@GetMapping("/{id}")
public ResponseEntity<UserVO> getUserById(@PathVariable Long id) {
return ResponseEntity.ok(userService.getUserById(id));
}
}两种返回风格不要混用
上面用的是 ResponseEntity<T>, 靠 HTTP 状态码表达结果; 下一章会介绍统一响应体 R<T>, 靠 body 里的 code 字段表达结果。两者是并列的选型, 一个项目里只该选一种:
| 风格 | 返回类型 | 前端判断方式 | 适合 |
|---|---|---|---|
| HTTP 语义 | ResponseEntity<UserVO> | 看 res.status | 对外开放 API, RESTful 规范严格 |
| 统一响应体 | R<UserVO> | 看 res.data.code | 内部前后端联调, 错误码体系复杂 |
混用会让前端拦截器写两套判断逻辑。国内业务项目多数选后者, 此时 Controller 直接返回 R.ok(data), 由全局异常处理器兜住失败分支
7.6 POJO 到底是什么
pojo 这个目录几乎出现在每个 Java 项目里, 但它的含义最容易被误解
字面上, POJO = Plain Old Java Object, "普通老式 Java 对象"。这是个历史遗留词: 早年 EJB 时代, 一个对象必须继承框架基类、实现一堆接口才能用, 非常笨重; 后来社区回归"就是个带字段和 getter / setter 的普通类", 为了跟 EJB 划清界限, 起名叫 POJO
所以 POJO 本身不是一个分层概念
严格说 Entity、DTO、VO 全都是 POJO —— 它们都只是普通 Java 类。你没法通过"是不是 POJO"来判断一个类属于哪一层
但在工程实践中, pojo/ 目录被约定成一个具体用途: 存放接口边界上的对象, 也就是 Request 和 Response。这是团队约定, 不是语言规定
实际项目里常见的组织方式是用一个外层类做命名空间, 把同一个接口的入参和出参收在一个文件里:
// 外层类只是个壳子, 从不实例化
public class UserApiPojo implements Serializable {
@Data
public static class CreateRequest implements Serializable {
@NotBlank(message = "用户名不能为空")
@Size(min = 2, max = 50, message = "用户名长度 2-50 个字符")
private String username;
@NotNull(message = "部门 ID 不能为空")
private Long departmentId;
}
@Data
public static class CreateResponse implements Serializable {
private Long userId;
}
@Data
public static class ListRequest implements Serializable {
private String keyword;
private Integer page = 1;
private Integer size = 10;
}
}使用时写全路径, 一眼看出归属:
@PostMapping
public R<UserApiPojo.CreateResponse> create(
@Valid @RequestBody UserApiPojo.CreateRequest request) {
return R.ok(userService.create(request));
}三个实践要点
- 内部类必须是
public static—— 非静态内部类会隐式持有外层实例引用, Jackson 反序列化时会失败 - 校验注解写在这里, 但要配合
@Valid才生效 —— Controller 参数上少写一个@Valid, 所有@NotBlank全部形同虚设, 这是极高频的疏漏 - 命名跟着项目走 —— 出口对象在不同项目里有
XxxVO/XxxResult/XxxResponse/XxxApiPojo多种叫法, 甚至同一个仓库里几种并存。改哪个模块就照那个模块现有的命名, 别自己另起一套
这套对象谱系还有更细的分法
DTO / VO / BO / PO 之间的区别、贫血模型与充血模型之争, 属于领域建模的范畴, 展开够写一篇独立文章。想深入可以看 Java 后端分层架构与领域对象
对刚上手的人来说, 先记住最小可用的三段就够: 入口对象收参 → Entity 落库 → 出口对象返回
7.7 Mapper: 编译期的对象转换器
分层带来一个副作用: 同一份数据在链路上要换好几次外衣, 每次都得手写一长串 set
// 手写转换: 啰嗦, 而且加字段时极易漏
UserVO vo = new UserVO();
vo.setId(user.getId());
vo.setUsername(user.getUsername());
vo.setEmail(user.getEmail());
// ... 还有 15 个字段MapStruct 就是来解决这件事的
先排除两个同名干扰项
"Mapper"在 Java 生态里被三个完全不同的东西共用, 初学时极易串味:
| 名字 | 是什么 | 什么时候遇到 |
|---|---|---|
org.mapstruct.Mapper | 对象转换器, 本节主角 | DTO ↔ Entity ↔ VO 转换 |
com.fasterxml.jackson.databind.ObjectMapper | JSON 序列化工具 | 手动转 JSON 字符串时 |
MyBatis 的 @Mapper | SQL 映射接口, 职责等同 Repository | 用 MyBatis 而非 JPA 的项目 |
如果你听过"Mapper 是写 SQL 的地方", 那说的是 MyBatis, 跟 MapStruct 毫无关系
MapStruct 的关键特征是编译期生成代码, 而不是运行时反射:
带来三个好处: 零反射开销、字段漏映射在编译期就警告、改字段名直接编译不过(而不是上线后才发现某个字段一直是 null)
基础用法 —— 只声明抽象方法, 同名字段自动对应:
@Mapper(componentModel = "spring") // 生成的实现类带 @Component, 可注入
public interface UserMapper {
// ① 单对象转换
UserVO toVO(User user);
// ② 集合转换: 也是全自动, 内部循环调用 ①
List<UserVO> toVOList(List<User> users);
// ③ 反向转换
User toEntity(UserApiPojo.CreateRequest request);
}注入后直接用:
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final UserMapper userMapper; // 注入生成的实现
public List<UserVO> list() {
return userMapper.toVOList(userRepository.findAll());
}
}字段对不上时, 用 @Mapping 逐个指定:
@Mapper(componentModel = "spring")
public interface UserMapper {
@Mapping(target = "departmentName", source = "department.name") // 取嵌套属性
@Mapping(target = "statusText", expression = "java(user.getStatus().getLabel())") // 自定义表达式
@Mapping(target = "password", ignore = true) // 显式不映射
UserVO toVO(User user);
}需要混写自定义逻辑时, 把 interface 改成 abstract class
接口只能放抽象方法。一旦某个转换需要写循环、排序或条件判断, 就改用抽象类 —— 具体方法手写, 单个对象的转换仍交给 MapStruct 生成:
@Mapper(componentModel = "spring")
public abstract class UserMapper {
// 手写: Set 入、有序 List 出, 顺带排序
public List<UserVO> toSortedVOList(Set<User> users) {
return users.stream()
.map(this::toVO) // 调下面这个自动生成的
.sorted(Comparator.comparing(UserVO::getUsername))
.collect(Collectors.toList());
}
// 留给 MapStruct 生成
protected abstract UserVO toVO(User user);
}前端有没有对应物
没有直接等价的。最接近的是手写 function toVO(dto) { return { ... } }, 或者 class-transformer 的 plainToInstance
差别在于 MapStruct 生成的是真实可读的 Java 代码 —— 编译后能在 target/generated-sources(Maven)或 build/generated(Gradle)里翻到 UserMapperImpl.java, 逐行确认它到底怎么赋值的。排查"某字段莫名是 null"时, 直接去读生成的代码比猜快得多
7.8 完整链路复盘
把七章的角色串成一条线:
各层职责速查:
| 角色 | 典型位置 | 职责 | 能否跨层暴露 |
|---|---|---|---|
| 入口对象 | pojo/XxxRequest | 接收参数 + 声明校验规则 | 只到 Service |
| Entity | entity/ | 映射存储结构 | 禁止返回给前端 |
| Repository | repository/ | 只管存取, 不含业务逻辑 | 只被 Service 调用 |
| Service | service/ | 业务编排、事务边界 | —— |
| DTO | dto/ | 层间传递的中间结果 | Service → Controller |
| 出口对象 | pojo/XxxVO | 前端契约, 脱敏裁剪 | 返回给前端 |
| Mapper | mapper/ | 对象之间的字段搬运 | 工具, 无状态 |
为什么不能直接把 Entity 返给前端
三个具体后果, 都很容易踩:
- 敏感字段泄露 ——
password、内部 ID、审计字段、软删除标记全被序列化出去 - 数据库与接口耦合 —— 表加个字段, 接口响应结构跟着变, 前端被动受影响
- 懒加载炸裂 ——
LAZY关联字段在事务结束后序列化, 直接抛LazyInitializationException
反过来也别为了分层而分层: 简单的查询用一个对象从头传到尾完全可以, 不必每层都造新类型
初学者的四个典型误解
自查一下, 这几点是最容易想歪的:
① "Entity 就是数据库表的映射" —— 方向对, 范围偏窄。准确说是"存储结构的映射"。同一个 entity/ 目录下可能混着两类: @Entity + @Table 映射关系型表, @Document 映射 Elasticsearch 索引。两者都叫 Entity, 但底层框架完全不同
② "Repository 里面封装了 ORM 工具" —— 反了。Repository 本身就是 ORM 框架提供的抽象层, 不是"内部含有 ORM"。JPA 这边的实际 ORM 实现是 Hibernate, ES 那边则由 Spring Data Elasticsearch 负责。不同存储各用各的 Repository 基接口, 而非一个 Repository 内部挂多个 ORM
③ "Repository 需要写实现类" —— 不需要, 这是跟 TypeORM 差别最大的地方。你只写接口, Spring 启动时用动态代理生成实现并注册成 Bean。方法名本身就是查询定义: findAllByStatusAndCreatedAtBetween 被解析成 where status = ? and created_at between ? and ?。TypeORM 是运行时传条件对象, 这里是编译前靠命名约定 —— 好处是拼错方法名启动就报错, 代价是复杂查询得回落到 @Query 或 Specification
④ "DTO 管请求和响应, VO 是最终出口" —— 流转方向理解对了, 但角色分配因项目而异。很多项目的入口和出口对象都放在 pojo/, dto/ 只装 Service 层的中间结果。别背教科书定义, 打开目录看现有代码怎么摆的
八、统一响应与异常处理
8.1 统一响应格式
前端最熟悉的后端约定: 统一的 JSON 响应结构
@Data
@Builder
public class R<T> {
private int code;
private String message;
private T data;
public static <T> R<T> ok(T data) {
return R.<T>builder()
.code(200)
.message("success")
.data(data)
.build();
}
public static <T> R<T> fail(int code, String message) {
return R.<T>builder()
.code(code)
.message(message)
.build();
}
}8.2 全局异常处理
类似 Express 的错误处理中间件, Spring Boot 用 @ControllerAdvice 捕获全局异常:
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 业务异常
*/
@ExceptionHandler(BusinessException.class)
public ResponseEntity<R<Void>> handleBusinessException(BusinessException e) {
log.warn("业务异常: {}", e.getMessage());
return ResponseEntity.badRequest().body(R.fail(400, e.getMessage()));
}
/**
* 参数校验异常 (DTO 的 @Valid 校验失败时触发)
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<R<Void>> handleValidException(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining(", "));
return ResponseEntity.badRequest().body(R.fail(400, message));
}
/**
* 兜底: 未知异常
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<R<Void>> handleException(Exception e) {
log.error("系统异常", e);
return ResponseEntity.internalServerError().body(R.fail(500, "服务器内部错误"));
}
}Express 对比
// Express 的全局错误处理中间件
app.use((err, req, res, next) => {
if (err instanceof BusinessError) {
return res.status(400).json({ code: 400, message: err.message })
}
res.status(500).json({ code: 500, message: '服务器内部错误' })
})Spring Boot 的 @RestControllerAdvice 做的事情完全一样, 只是用注解代替了中间件
九、Spring AOP 与拦截器
9.1 AOP 面向切面编程
AOP 是 Spring 的核心特性之一。如果说依赖注入解决了"对象怎么创建和组装"的问题, 那 AOP 解决的就是"怎么在不修改业务代码的情况下, 统一添加日志、权限、事务等横切关注点"
对 Express / Koa 中间件的类比
| Spring 机制 | Express / Koa 对应 | 执行时机 |
|---|---|---|
Filter | app.use(cors()) / app.use(bodyParser()) | 所有请求, 最外层 |
Interceptor | app.use(authMiddleware) | 进入 Controller 前后 |
@Aspect (AOP) | NestJS 的 @UseInterceptors() | 方法执行前后 |
@ControllerAdvice | app.use(errorHandler) | 异常发生时 |
9.2 自定义拦截器
@Slf4j
@Component
public class RequestLogInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) {
long startTime = System.currentTimeMillis();
request.setAttribute("startTime", startTime);
log.info("[请求开始] {} {}", request.getMethod(), request.getRequestURI());
return true; // true = 放行, false = 拦截
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
long startTime = (Long) request.getAttribute("startTime");
long duration = System.currentTimeMillis() - startTime;
log.info("[请求结束] {} {} - {}ms", request.getMethod(), request.getRequestURI(), duration);
}
}
// 注册拦截器
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final RequestLogInterceptor requestLogInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(requestLogInterceptor)
.addPathPatterns("/api/**") // 拦截 /api/ 下的所有请求
.excludePathPatterns("/api/v1/auth/**"); // 排除登录接口
}
}9.3 自定义 AOP 切面
实现一个方法执行耗时统计的切面:
@Aspect
@Component
@Slf4j
public class PerformanceAspect {
// 切入点: 匹配所有 Service 层的 public 方法
@Around("execution(* com.example.demo.service.*.*(..))")
public Object logPerformance(ProceedingJoinPoint joinPoint) throws Throwable {
String methodName = joinPoint.getSignature().toShortString();
long start = System.currentTimeMillis();
try {
Object result = joinPoint.proceed(); // 执行目标方法
long duration = System.currentTimeMillis() - start;
if (duration > 500) {
log.warn("[慢方法] {} 耗时 {}ms", methodName, duration);
}
return result;
} catch (Exception e) {
long duration = System.currentTimeMillis() - start;
log.error("[方法异常] {} 耗时 {}ms, 异常: {}", methodName, duration, e.getMessage());
throw e;
}
}
}十、数据库进阶
10.1 动态查询 (JPA Specification)
当查询条件不固定时(比如搜索页面的多个可选筛选条件), 使用 Specification 动态拼接 WHERE 子句:
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
public Page<UserVO> searchUsers(String keyword, User.UserStatus status,
int page, int size) {
Pageable pageable = PageRequest.of(page - 1, size);
Specification<User> spec = (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
// 关键词搜索 (用户名或邮箱)
if (StringUtils.hasText(keyword)) {
Predicate nameLike = cb.like(root.get("username"), "%" + keyword + "%");
Predicate emailLike = cb.like(root.get("email"), "%" + keyword + "%");
predicates.add(cb.or(nameLike, emailLike));
}
// 状态筛选
if (status != null) {
predicates.add(cb.equal(root.get("status"), status));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
return userRepository.findAll(spec, pageable).map(this::toVO);
}
}Prisma 对比
// Prisma 的动态查询
const users = await prisma.user.findMany({
where: {
OR: keyword ? [
{ username: { contains: keyword } },
{ email: { contains: keyword } },
] : undefined,
status: status ?? undefined,
},
skip: (page - 1) * size,
take: size,
})两者的思路完全一致: 根据传入条件动态拼接查询
10.2 复杂关联查询
// 一对多关系
@Entity
@Table(name = "t_department")
@Data
public class Department {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// 一个部门有多个用户
@OneToMany(mappedBy = "department", fetch = FetchType.LAZY)
private List<User> users;
}
// 多对一关系
@Entity
@Table(name = "t_user")
@Data
public class User {
// ...其他字段
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "department_id")
private Department department;
}N+1 查询问题
JPA 默认使用懒加载(LAZY), 循环访问关联对象时会产生 N+1 查询问题(和 TypeORM / Sequelize 一样)。解决方案:
- JPQL fetch join:
SELECT u FROM User u JOIN FETCH u.department - EntityGraph:
@EntityGraph(attributePaths = {"department"}) - 直接用 DTO 投影: 不查关联实体, 只查需要的字段
十一、Redis 缓存集成
11.1 基础配置
spring:
data:
redis:
host: localhost
port: 6379
password: ""
database: 011.2 使用 RedisTemplate
@Service
@RequiredArgsConstructor
public class CacheService {
private final RedisTemplate<String, Object> redisTemplate;
// 缓存用户信息 (类似 Node.js 的 redis.set)
public void cacheUser(Long userId, UserVO user) {
String key = "user:" + userId;
redisTemplate.opsForValue().set(key, user, Duration.ofMinutes(30));
}
// 获取缓存
public UserVO getCachedUser(Long userId) {
String key = "user:" + userId;
return (UserVO) redisTemplate.opsForValue().get(key);
}
// 删除缓存
public void evictUser(Long userId) {
redisTemplate.delete("user:" + userId);
}
}默认序列化器会让这段代码翻车
RedisTemplate 默认用 JDK 序列化, 有两个后果: 一是 redis-cli 里看到的是一串乱码, 二是 UserVO 增删字段后, 旧缓存反序列化直接抛 ClassCastException。而上面 (UserVO) 这种强制转型编译期不报错, 问题全留到运行时
必须显式配置 JSON 序列化器:
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
// 值用 JSON, 可读且跨语言
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
template.afterPropertiesSet();
return template;
}
}即便如此, 缓存对象也应保持向后兼容: 只加字段不改字段类型, 结构大改时换一个新的 key 前缀
11.3 注解式缓存
Spring Cache 提供了更优雅的声明式缓存, 只需加注解:
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
// 查询时自动缓存, key = "user::1"
@Cacheable(value = "user", key = "#id")
public UserVO getUserById(Long id) {
log.info("从数据库查询用户: {}", id);
User user = userRepository.findById(id)
.orElseThrow(() -> new BusinessException("用户不存在"));
return toVO(user);
}
// 更新时自动更新缓存
@CachePut(value = "user", key = "#id")
public UserVO updateUser(Long id, UpdateUserDTO dto) {
// ...更新逻辑
}
// 删除时自动清除缓存
@CacheEvict(value = "user", key = "#id")
public void deleteUser(Long id) {
userRepository.deleteById(id);
}
}缓存注解速查
| 注解 | 作用 | 等效操作 |
|---|---|---|
@Cacheable | 先查缓存, 没有才执行方法 | cache.get(key) ?? (await fn()) |
@CachePut | 执行方法并更新缓存 | cache.set(key, fn()) |
@CacheEvict | 执行方法并删除缓存 | cache.del(key) |
十二、安全认证 (JWT)
12.1 认证流程
12.2 JWT 工具类
@Component
@RequiredArgsConstructor
public class JwtUtil {
private final AppConfig appConfig;
// 生成 Token
public String generateToken(Long userId, String username) {
return Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("username", username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis()
+ appConfig.getJwt().getExpiration()))
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
// 解析 Token
public Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
}
// 从 Token 中获取用户 ID
public Long getUserId(String token) {
return Long.parseLong(parseToken(token).getSubject());
}
private Key getSigningKey() {
// 注意: HS256 要求密钥至少 256 bit (32 字节), 过短会直接抛 WeakKeyException
byte[] keyBytes = appConfig.getJwt().getSecret().getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}
}上面是 jjwt 0.11.x 的写法
jjwt 0.12 起做了一次 API 重命名, setXxx 系列和 parserBuilder() 全部标记废弃。如果你用的是新版本, 对应关系如下:
| 0.11.x | 0.12+ |
|---|---|
setSubject(...) | subject(...) |
setIssuedAt(...) | issuedAt(...) |
setExpiration(...) | expiration(...) |
signWith(key, SignatureAlgorithm.HS256) | signWith(key) (自动推导算法) |
Jwts.parserBuilder() | Jwts.parser() |
setSigningKey(...) | verifyWith(...) |
parseClaimsJws(t).getBody() | parseSignedClaims(t).getPayload() |
另外密钥不要硬编码在 application.yml 里提交进仓库, 应走环境变量或配置中心
12.3 Security 过滤器链配置
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 禁用 CSRF (前后端分离不需要)
.csrf(csrf -> csrf.disable())
// 禁用 Session (用 JWT 无状态认证)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 路由权限配置
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll() // 登录注册公开
.requestMatchers("/actuator/**").permitAll() // 监控端点公开
.anyRequest().authenticated() // 其他都需要认证
)
// 在 UsernamePasswordAuthenticationFilter 之前插入 JWT 过滤器
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}12.4 JWT 过滤器
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserRepository userRepository;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
try {
Long userId = jwtUtil.getUserId(token);
User user = userRepository.findById(userId).orElse(null);
if (user != null) {
// 将用户信息放入 SecurityContext, 后续可通过 SecurityContextHolder 获取
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(user, null, List.of());
SecurityContextHolder.getContext().setAuthentication(authToken);
}
} catch (ExpiredJwtException e) {
// 过期是预期内的高频情况, 降级为 debug, 避免日志被刷爆
log.debug("Token 已过期: {}", e.getMessage());
} catch (JwtException | IllegalArgumentException e) {
// 签名不合法 / 格式错误, 可能是攻击探测, 留一条 warn
log.warn("Token 解析失败: {}", e.getMessage());
}
}
filterChain.doFilter(request, response);
}
}十三、消息队列集成
在大型项目中, 很多操作不需要同步完成(比如发送邮件、生成报告、写日志)。消息队列可以将这些耗时任务异步化
13.1 消息队列概念
与前端的类比
消息队列就像浏览器的 postMessage / BroadcastChannel, 或者 Node.js 的 EventEmitter:
// Node.js EventEmitter
emitter.emit('user:registered', { userId: 1 })
// 多个消费者监听
emitter.on('user:registered', sendWelcomeEmail)
emitter.on('user:registered', initUserProfile)区别在于消息队列是跨进程、可持久化的, 即使消费者宕机, 消息也不会丢失
13.2 使用 RocketMQ
// 生产者: 发送消息
@Service
@RequiredArgsConstructor
public class MessageProducer {
private final RocketMQTemplate rocketMQTemplate;
public void sendUserRegisteredEvent(Long userId, String username) {
UserRegisteredEvent event = new UserRegisteredEvent(userId, username);
rocketMQTemplate.convertAndSend("user-registered-topic", event);
}
// 延迟消息 (比如: 注册后 30 分钟发问卷)
public void sendDelayedMessage(Long userId) {
Message<Long> message = MessageBuilder.withPayload(userId).build();
// delayLevel: 1=1s, 2=5s, 3=10s, ..., 16=2h
rocketMQTemplate.syncSend("survey-topic", message, 3000, 14); // 延迟 10 分钟
}
}
// 消费者: 处理消息
@Slf4j
@Component
@RocketMQMessageListener(
topic = "user-registered-topic",
consumerGroup = "email-consumer-group"
)
public class WelcomeEmailConsumer implements RocketMQListener<UserRegisteredEvent> {
@Override
public void onMessage(UserRegisteredEvent event) {
log.info("发送欢迎邮件给用户: {}", event.getUsername());
// 发送邮件逻辑...
}
}十四、定时任务
14.1 Spring 内置定时任务
适合简单的单机定时任务:
@Slf4j
@Component
@EnableScheduling
public class ScheduledTasks {
// 每天凌晨 2 点执行 (Cron 表达式, 和 Node.js 的 node-cron 一样)
@Scheduled(cron = "0 0 2 * * ?")
public void cleanExpiredData() {
log.info("开始清理过期数据...");
// 清理逻辑
}
// 每 5 分钟执行一次
@Scheduled(fixedRate = 300000)
public void healthCheck() {
log.info("执行健康检查...");
}
}14.2 分布式定时任务 (XXL-Job)
当应用部署多个实例时, 内置的 @Scheduled 每个实例都会执行。分布式场景需要用 XXL-Job 这类调度平台:
@Slf4j
@Component
public class DataSyncJob {
@XxlJob("dataSyncJobHandler")
public void execute() {
log.info("XXL-Job 触发数据同步任务");
// 分布式环境下只有一个实例会执行
}
}十五、项目实战架构
综合以上所有知识, 一个生产级的 Spring Boot 项目架构如下:
多模块项目结构
企业级项目通常采用多模块结构, 用 Gradle 或 Maven 管理:
my-project/
├── build.gradle # 根构建文件
├── settings.gradle # 模块注册
├── my-common/ # 公共模块
│ ├── my-common-core/ # 核心工具类
│ └── my-common-pojo/ # 公共 POJO / DTO
├── my-platform/ # 平台模块
│ └── my-platform-auth/ # 认证授权
├── my-user/ # 用户模块
│ ├── my-user-common/ # 模块内公共
│ ├── my-user-service/ # 业务实现
│ └── my-user-api/ # Feign 接口 (微服务调用)
├── my-order/ # 订单模块
│ ├── my-order-common/
│ ├── my-order-service/
│ └── my-order-api/
└── docker-compose.yml # 基础设施十六、Java 工具链全景 (对照前端生态)
16.1 核心工具生态对比
Java 的工具链按职责分为五层, 每层都能在前端找到对应物:
核心认知差异
- Java 的构建工具 (Maven/Gradle) 管到打包, 生产启动直接用
java -jar - 前端的构建工具 (Webpack/Vite) 只负责打包, 运行靠
node server.js - 两者的工具重叠度在 "依赖管理 + 打包" 这两个职责上, 但启动方式完全不同
16.2 构建工具选型 (Maven vs Gradle)
| 维度 | Maven | Gradle | 前端类比 |
|---|---|---|---|
| 配置语言 | XML | Groovy / Kotlin DSL | Maven ≈ JSON, Gradle ≈ JS config |
| 构建速度 | 中等 | 快 (增量构建 + 缓存) | Gradle ≈ Turborepo 的缓存策略 |
| 学习曲线 | 平缓 (约定多) | 陡峭 (灵活但复杂) | Maven ≈ Next.js, Gradle ≈ Webpack |
| 生态成熟度 | 最全 (企业标配) | 增长中 (Android 标配) | Maven ≈ npm 的地位 |
| 依赖仲裁 | 路径最短优先 | 最高版本优先 | 两者都比 npm 扁平化更激进 |
| Lockfile | 无官方标准 | 需手动开启 | 比 package-lock.json 松 |
选型建议: 企业 Java 项目默认 Maven, 大型 monorepo 或需要自定义构建流程时选 Gradle
16.3 依赖机制的三个关键差异
1. 没有 node_modules
结论: Java 项目目录干净, 删了重建秒级; 前端的 node_modules 可能几个 GB
2. 扁平 classpath 强制仲裁
// 前端: 允许嵌套, A 用 lodash@3, B 用 lodash@4 可共存
node_modules/
├── package-a/node_modules/lodash@3.x
└── package-b/node_modules/lodash@4.x
// Java: classpath 扁平, 同一类名只能有一份
// Maven 按 "路径最短" 仲裁, 距离相同则先声明者胜
// Gradle 按 "版本最高" 仲裁后果: Java 的依赖冲突表现为运行时 NoSuchMethodError, 排查靠:
./mvnw dependency:tree # 类似 pnpm why
./gradlew dependencies --configuration runtimeClasspath3. 生命周期约束 vs 自由脚本
Maven 有三套生命周期, 每个阶段顺序固定:
# Maven 的 default 生命周期 (部分)
validate → compile → test → package → install → deploy
# 执行 package 会自动先跑 validate + compile + test
./mvnw package # 相当于强制跑了单测
# 前端的 npm scripts 完全自由
npm run build # 不会自动跑测试, 除非你手动写 "prebuild": "test"语义陷阱
mvn install: 推到 本地仓库~/.m2/repository(供其他项目引用)mvn deploy: 推到 远程仓库 (类似npm publish)npm install: 拉依赖 (≈mvn dependency:resolve)
三个 install 语义完全不同!
16.4 启动服务的两条路径
核心原则:
- 开发用 IDE (能断点调试), 或用
mvnw spring-boot:run(类似npm run dev) - 生产用
java -jar(不依赖构建工具), 外面套 systemd / K8s 管重启
不要用 mvn spring-boot:run 上生产 — 它依赖源码目录和构建工具, 重启信号处理不干净
16.5 版本管理 (类似 nvm)
# SDKMAN (推荐, 类似 nvm)
sdk list java
sdk install java 21-tem
sdk use java 17.0.9-tem # 当前 shell 切换
# 替代品
brew install jenv # 需要手动配置 shims
brew install asdf # 通用版本管理器
brew install mise # Rust 实现的快速版本切换16.6 运行时诊断 (Java 独有优势)
前端的运行时诊断主要靠 Chrome DevTools 或 node --inspect, 能力有限; Java 有一整套工具链, 核心场景是 不重启诊断线上问题
JDK 自带命令行工具
jps -l # 列出 Java 进程和主类 (类似 ps aux | grep java)
jstack <pid> # 打印线程栈, 查死锁和卡顿
jmap -histo <pid> # 堆内对象分布, 查内存泄漏
jstat -gc <pid> 1000 # 每秒打印 GC 统计 (类似 top 的内存视图)
jcmd <pid> GC.heap_info # 万能诊断入口, 逐步取代上面几个老命令Arthas (阿里开源, 线上诊断神器)
# 下载并 attach 到进程
curl -O https://arthas.aliyun.com/arthas-boot.jar
java -jar arthas-boot.jar
# 实时监控方法调用 (入参 + 返回值 + 耗时)
watch com.example.UserService getUser "{params, returnObj, costInMillis}" -x 2
# 查看 JVM 实时指标
dashboard
# 反编译线上 class (验证部署是否正确)
jad com.example.UserService前端最接近的: Chrome DevTools 远程调试, 但成熟度差几个量级
图形化 Profiling
- VisualVM / JDK Mission Control: 需单独下载, CPU / 内存 profiling
- JFR (Java Flight Recorder): 低开销飞行记录仪, JDK 11+ 免费, 生产可用
16.7 测试与质量工具
Testcontainers 的威力
你熟 Docker, 这个工具会让你眼前一亮 — 它用 Docker 起 真实的 MySQL / Redis 跑集成测试:
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
@Test
void testRealDatabase() {
// 测试代码连的是真 MySQL, 不是 mock, 没有 "测试通过但生产挂" 的尴尬
}前端一般用 msw 拦 HTTP (假装有后端), Java 是 "真起一个后端"
Testcontainers 最初来自 Java 生态, 现已支持 Node.js / Python / Go 等语言
格式化与检查对照:
# Spotless 一键格式化 (类似 Prettier)
./mvnw spotless:apply
# Checkstyle 风格检查 (类似 ESLint 的格式规则)
./mvnw checkstyle:check
# SpotBugs 静态缺陷扫描 (类似 ESLint 的逻辑规则)
./mvnw spotbugs:check16.8 容器化与原生镜像
Jib: 不需要 Dockerfile
# 直接构建镜像 (不需要 Docker daemon)
./mvnw jib:dockerBuild
# 推到远程仓库
./mvnw jib:build -Dimage=myrepo/myapp:1.0优势: 分层缓存优化 (依赖层和代码层分离), 构建速度快
GraalVM Native Image
# 编译成原生二进制 (启动毫秒级, 内存占用小)
./mvnw -Pnative native:compile
# 适合 Serverless / K8s 快速扩容场景
# 但编译慢, 反射 / 动态代理需要额外配置前端类比: bun build --compile 也能编成二进制, 但两者机制差异很大 (GraalVM 是 AOT 编译, bun 是打包 V8 引擎)
16.9 完整对照速查表
| Java | 前端 | 说明 |
|---|---|---|
pom.xml / build.gradle | package.json | 依赖声明 + 脚本 + 元信息 |
| Maven / Gradle | pnpm + Vite 合体 | 一个工具管两件事: 依赖和打包 |
~/.m2/repository | pnpm 全局 store | 跨项目共享缓存 (但引用机制不同) |
./mvnw / ./gradlew | corepack / packageManager | 锁构建工具版本 |
mvn package | vite build | 产出可部署产物 |
| fat jar | dist/ + node_modules | jar 把依赖也塞进去了 |
java -jar app.jar | node server.js | 真正的启动命令 |
mvn spring-boot:run | npm run dev | 开发态, 带自动编译 |
| spring-boot-devtools | HMR | 但只能整应用重启, 不是模块级热替换 |
| JUnit + Mockito | Vitest + vi.mock | 单测框架 + mock 工具 |
| Testcontainers | - | Java 独有: 用 Docker 起真实依赖跑测试 |
| Spotless | Prettier | 格式化 |
| Checkstyle / SpotBugs | ESLint | 风格检查 + 缺陷扫描 |
| Arthas | Chrome DevTools | 但 Arthas 能 attach 线上进程不重启诊断 |
| Jib | buildpacks / nixpacks | 不写 Dockerfile 出镜像 |
| Maven BOM | pnpm catalog: (9.5+) | 统一依赖版本 |
mvn dependency:tree | pnpm why | 查依赖路径 |
| Gradle build cache | Turborepo / Nx cache | 跨项目共享构建产物 |
编译是硬性要求
前端可以 node index.js 直接跑源码 (或 ts-node 即时编译), Java 必须先 .java → .class
例外: JDK 11+ 支持 java HelloWorld.java 单文件源码直跑 (背后还是先编译), 但多文件项目仍需 Maven/Gradle
所以 Java 里 "改代码生效" 最快是重启级别, 前端 HMR 那种体验不存在
十七、常用命令速查
构建与运行
# Maven
./mvnw clean install # 类似 npm install && npm run build
./mvnw spring-boot:run # 类似 npm run dev
./mvnw package -DskipTests # 打包跳过测试
# Gradle
./gradlew clean build # 构建
./gradlew bootRun # 开发运行
./gradlew bootJar # 打成可执行 JAR
# Docker 部署
docker build -t my-app .
docker run -p 8080:8080 my-app常用 Actuator 端点
# 健康检查 (类似 Node.js 的 /healthz)
curl http://localhost:8080/actuator/health
# 查看所有配置
curl http://localhost:8080/actuator/env
# Prometheus 指标
curl http://localhost:8080/actuator/prometheus十八、从 Node.js 到 Spring Boot 的思维转换
写给前端开发者的建议
- 拥抱 IDE: Java 开发离不开 IntelliJ IDEA, 它的代码补全、重构、调试能力远超 VS Code 写 Java 的体验
- 不要怕样板代码: Java 确实比 JS/TS 啰嗦, 但 Lombok + IDEA 快捷键可以大幅缓解
- 理解编译期 vs 运行时: Java 的很多错误在编译期就能发现, 这是优势而非束缚
- 善用 Spring 文档: Spring 官方文档 质量极高, 是最好的学习资源
- AI 辅助开发: 借助 AI 工具快速理解 Java 语法和 Spring 惯用写法, 可以大幅降低学习曲线